Skip to content

fix(bin): prevent cross-home firstmate operations - #73

Merged
bingb0t5 merged 21 commits into
mainfrom
fm/fm-spawn-secondmate-fm-home-guard
Sep 16, 2026
Merged

bingb0t5 merged 21 commits into
mainfrom
fm/fm-spawn-secondmate-fm-home-guard

Conversation

@bingb0t5

@bingb0t5 bingb0t5 commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Intent

CEO overview

  • What is changing: An agent home that is pointed at the wrong home now stops instead of quietly operating on it. Each home keeps its own private records - preferences, learnings, work queue, and running workers - and a mate aimed at another home could read and rewrite that home's records with no complaint. This adds a seatbelt against that mistake, refused before anything is written. It is misrouting prevention, not authentication, and the limits are written down rather than glossed over.
  • Why it matters: This actually happened twice. A mate started a worker inside the main home instead of its own, and a mate's memory sweep read and rewrote the main home's preference and learning records. Those records are the accumulated operating knowledge of the whole fleet, they are not in version control, and nothing stopped either event or reported it afterwards.
  • Customer or business impact: A mispointed mate now stops with a message naming the homes and identities involved, instead of quietly corrupting shared knowledge, which is what both incidents were. Work that was already correct is untouched: a mate still runs its own workers and sweeps its own memory, and the main home still reaches the mates it owns for the routine sweep, work handoff, and mate startup. The protection covers ordinary mate execution. It does not withstand a process that clears its own environment, and it does not extend to remotely placed homes whose identity cannot be confirmed on their own host.
  • Risk and rollout: Low and self-limiting. A refusal needs a signal that positively says the running process belongs elsewhere, and a home's identity counts only when the fleet registry confirms that exact path, so a stale marker left in a recycled working copy cannot cause a false refusal. Mates already running, and remotely placed mates, keep working on their own homes as they pick up this version rather than being locked out. Nothing is removed and no existing behaviour is relaxed.

What changed technically

The environment variable FM_HOME selects which home's data, state, config, and projects directory a command operates on, and every fm-* entrypoint resolves it identically. A secondmate process whose FM_HOME named a different home therefore operated on that home with full authority. The existing primary-only domain-mate check in bin/fm-spawn.sh could not stop the spawn incident, because it inspects $FM_HOME, which is the value that was already wrong, rather than the running process.

New library bin/fm-home-identity-lib.sh owns the refusal and how a home's identity is read for it; bin/fm-home-seed.sh still owns writing the marker. Guarded entrypoints are bin/fm-spawn.sh for every spawn kind including --secondmate, bin/fm-startup-memory-budget.sh and bin/fm-stow-cascade.sh for stow memory operations, and bin/fm-send.sh for steering. Each refuses with exit status 4 after FM_HOME is resolved and before anything is read for a write, spawned into, or steered, including before any library that would create the target's state directory.

Accepted scope, settled during review: this is an accidental-misrouting guard, not process provenance or impersonation protection, and the library's LIMIT section states that plainly. A process can unset or alter its inherited environment, leaving only code-root protection. Remotely placed homes run from their host's separate tracked code root, which carries no identity marker, so this guard neither establishes a remote session's own-home provenance nor protects a same-host sibling from it. Use of the primary home's own scripts by absolute path gets primary-only containment rather than sibling protection. The identity marker is a routing convention and never authorization on its own.

Two signals refuse, either alone. The code-root signal is the identity of the home whose bin directory is executing, resolved from the library's own physical location so FM_ROOT_OVERRIDE cannot relabel it; it covers a mate reaching the primary home and is the only signal covering a mate reaching a sibling. It counts only when corroborated: the code root must carry the .fm-secondmate-parent binding whose local parent registers that id at that exact path in data/secondmates.md. That requirement exists because the marker is gitignored and a pooled task worktree can be re-leased from a retired home with its marker still present; a retired marker is registered nowhere and establishes nothing. The launch-binding signal uses FM_PUBLIC_FOLLOWUP_PRIMARY_HOME, already stamped into every secondmate session, to contain accidental selection of the primary home through the primary's own scripts.

Directory overrides are covered too, since FM_STATE_OVERRIDE and its siblings reach the same directories FM_HOME selects. An override is refused only when it resolves inside another registry-corroborated home; an unrelated explicit directory keeps working, which preserves the alternate-directory capability the test suite exercises. Identity validation accepts exactly the character and shape contract the shared registry parser accepts, with no stricter local policy, and rejects an unsafe marker - a symlink, a directory, an empty file, a second line, or NUL bytes - rather than silently treating it as the unmarked primary.

The refusal is one-way, which protects the primary home's records from ordinary mate and worker execution while keeping deliberate correct selections working. Neither signal fires when the executing home is the primary, so the memory cascade running each mate's own accounting, backlog handoff into a mate's queue, and standing a mate up all continue unchanged. A home operating on itself is always allowed. Refusal diagnostics name the resolved identities and canonical paths involved and carry no credential or message content.

Documentation is updated without duplicating the contract: the library header owns it, docs/architecture.md gains a cross-home boundary section beside the existing gate boundary, docs/configuration.md and the four entrypoint help surfaces carry pointers, and the stow and secondmate-provisioning skills carry their trigger lines. A pre-existing truncation in the startup-memory help output is fixed in the same pass.

Also fixed, found while validating: four test fixtures built their fake code roots by copying the runner's checkout without excluding the gitignored home identity files, so running the suite from a real secondmate home handed that home's identity to the fixture.

Validation

  • Checks passed: The full no-mistakes pipeline on the final head - review, test, document, lint, push, PR, and continuous integration. Hosted checks are green: lint, both portable parallel behaviour lanes, and final-head determination. A new regression suite, tests/fm-home-identity.test.sh, covers mismatched and valid routing across each guarded interface plus sibling routing, unsafe and uncorroborated markers, override handling, and the documented limits. Before the pipeline ran, both original incidents were reproduced end to end and then shown refused, with the primary home's records verified unchanged, and the new suite was run against a build with only the refusal calls removed to confirm no assertion passes vacuously.
  • Checks not run: The complete portable serial behaviour lane was not run to completion locally; continuous integration owns that broad regression, as this repository intends, and it is skipped in this pull request's check set along with the Herdr and stock-macOS lanes, which need hardware and credentials this run did not have.
  • Evidence and limitations: Scope narrowed during review from a general cross-home boundary to accidental-misrouting prevention, and the environment-unset, remote-session, and absolute-primary-bin limits are documented in the library rather than claimed closed. Review ran twelve fix rounds and continuous integration caught one real regression, which was repaired and revalidated. Three behaviour suites fail independently of this change and are reported rather than claimed green: two fail identically on the main baseline, and one allows a one second budget that a loaded host cannot meet. Several further suites failed only in the authoring worktree, which was re-leased from a retired secondmate home and still carries its identity files; they pass in a clean clone of the same commit, and that leftover state was deliberately preserved rather than deleted.

Module-boundary decision

Current module retained: the four entrypoints keep their existing boundaries and the refusal lives in one shared helper library beside the existing gate-refusal helper, following that established pattern, so the contract is stated once and each entrypoint carries only a call and a pointer.

Decision needed

No decision required.

What Changed

  • Add a shared home-identity guard that detects corroborated cross-home routing and refuses unsafe operations with status 4.
  • Apply the guard to spawning, steering, startup-memory accounting, and stow cascades, including protected directory overrides.
  • Document the boundary and its limits, add routing regression coverage, and prevent remote test fixtures from copying home-identity markers.

Risk Assessment

✅ Low: Captain, the change is a bounded, source-consistent accidental-misrouting guard with documented limitations and no substantiated remaining defect in the reviewed paths.

Testing

Focused end-to-end CLI validation passed: the new regression suite exercised all guarded interfaces and routing boundaries, while direct target and base-commit fixtures demonstrated the incident changing from a primary inbox write to an exit-4 refusal with no protected write. No UI applies to this CLI and filesystem-safety change.

  • Live validation: ✅ go - 4 of 4 scenarios driven live against the product
Scenario Result Live Evidence
A mate cannot steer the primary through its own bin ✅ pass live Baseline and target CLI transcripts
Protected-home ancestor overrides are refused while unrelated missing overrides remain usable ✅ pass live Target CLI refusal and override transcript; focused suite transcript
Guarded spawn, send, startup-memory, and stow commands preserve valid own-home and primary-to-mate routing ✅ pass live Focused end-to-end suite transcript
Only safe registry-corroborated identities participate in routing protection ✅ pass live Focused end-to-end suite transcript
Evidence: Target CLI refusal and override transcript

Source: Target CLI refusal and override transcript

SCENARIO: mate steering primary through its own bin
exit=4
error: fm-send refuses a cross-home operation: this process runs from the 'mate-a' secondmate home (/tmp/fm-home-identity-live.4nCoT4/mate) but FM_HOME selects the 'primary' home (/tmp/fm-home-identity-live.4nCoT4/primary).
error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: code-root]
protected_inbox_exists=no

SCENARIO: mate spawning through a missing state subpath under primary
exit=4
error: fm-spawn refuses a cross-home operation: FM_STATE_OVERRIDE selects /tmp/fm-home-identity-live.4nCoT4/primary/scratch/missing-state inside the protected 'primary' home (/tmp/fm-home-identity-live.4nCoT4/primary) state surface instead of the selected 'mate-a' home (/tmp/fm-home-identity-live.4nCoT4/mate).
error: a home may only operate on itself; another home - the primary home above all - is read-only from here. Set FM_HOME to this home, or run the operation from the owning home's own session. [signal: surface-override]
protected_subpath_exists=no

SCENARIO: primary uses unrelated missing state override
exit=1
/tmp/fm-home-identity-live.4nCoT4/primary/bin/fm-spawn.sh: line 1768: cd: /tmp/fm-home-identity-live.4nCoT4/project: No such file or directory
cross_home_refusal=no
unrelated_state_created=yes
Evidence: Baseline incident reproduction transcript

Source: Baseline incident reproduction transcript

BASELINE SCENARIO: mate steering primary through its own bin
exit=0
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the requested message WILL still be sent.
●  repair missing watcher supervision with a foreground checkpoint: bin/fm-watch-checkpoint.sh --seconds 180.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
fm-send: doorbell did not reach fm-primary-task; the steer is durably recorded at /tmp/fm-home-identity-baseline.UX9eG0/primary/state/primary-task.inbox/001.msg and the watcher will re-ring
protected_inbox_exists=yes
Evidence: Focused end-to-end suite transcript

Source: Focused end-to-end suite transcript

FM_TEST_BEGIN 2026-09-16T04:19:01Z tests/fm-home-identity.test.sh family=secondmate expected_gate_skip=none
ok - stow memory accounting refuses another home and keeps valid routing
ok - an inherited identity bypass cannot disable the boundary
ok - surface overrides cannot escape the selected home
ok - remote control accepts only its authenticated endpoint layout
ok - fm-send refuses another home and keeps valid routing
ok - fm-spawn refuses another home and preserves the domain-mate boundary
ok - fm-stow-cascade refuses another home and keeps the primary sweep
ok - the secondmate session binding refuses its bound primary home
ok - an unreadable home identity refuses instead of collapsing to primary
ok - identity markers accept the registry id charset
ok - only a registry-corroborated marker establishes a home identity
ok - an own-home exception requires the canonical registered path
ok - a marker identity of primary remains distinct from marker absence
ok - startup-memory-budget help renders its full contract
FM_TEST_END 2026-09-16T04:19:15Z tests/fm-home-identity.test.sh exit=0 duration_ms=13768 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=13826
FM_TEST_SUMMARY_FAMILY family=secondmate count=1 duration_ms=13768 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-home-identity.test.sh duration_ms=13768
fm-test-run: wrote timing artifact: ~/.no-mistakes/evidence/01M2M5WNS1YA07KND9XEC7VH61/fm-home-identity-results.json
Evidence: Focused test timing record

Source: Focused test timing record

{
  "families": [
    {
      "count": 1,
      "duration_ms": 13768,
      "failed": 0,
      "name": "secondmate"
    }
  ],
  "finished_at": "2026-09-16T04:19:15Z",
  "run_id": "fm-test-run-1789532341869-1174918",
  "scripts": [
    {
      "duration_ms": 13768,
      "exit": 0,
      "expected_gate_skip": "none",
      "family": "secondmate",
      "gate_skip": false,
      "path": "tests/fm-home-identity.test.sh"
    }
  ],
  "selection": "scripts",
  "started_at": "2026-09-16T04:19:01Z",
  "summary": {
    "duration_ms": 13826,
    "failed": 0,
    "skipped_gate": 0,
    "total": 1
  }
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (2) ✅
  • 🚨 bin/fm-home-identity-lib.sh:315 - Intent requires: “An override is refused only when it resolves inside another registry-corroborated home; an unrelated explicit directory keeps working.” Lines 315-318 instead turn any non-canonicalizable override into an exit-4 refusal. For example, a primary running FM_STATE_OVERRIDE=/tmp/new-state bin/fm-spawn.sh ... now exits before the pre-existing mkdir -p "$STATE" path can create that explicit state directory. Preserve the documented alternate-directory behavior by allowing an unrelated missing directory while still detecting an existing/canonicalizable protected-home ancestor.

🔧 Fix applied.
1 error still open:

  • 🚨 bin/fm-home-identity-lib.sh:310 - The required criterion says an override must be rejected when a registered protected home is an ancestor of its target. The new check only recognizes <protected-home>/<surface> paths. For example, a mate running its own bin with FM_HOME=<mate> and FM_STATE_OVERRIDE=<primary> (or <primary>/scratch) passes lines 310-320 because neither path matches <primary>/state; fm-spawn then uses that path as STATE and can create/write task records there. Reject any override whose canonical existing-prefix walk has a corroborated protected home ancestor, rather than only its canonical surface subdirectory.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 4 of 4 scenarios driven live against the product
Scenario Result Live Evidence
A mate cannot steer the primary through its own bin ✅ pass live Baseline and target CLI transcripts
Protected-home ancestor overrides are refused while unrelated missing overrides remain usable ✅ pass live Target CLI refusal and override transcript; focused suite transcript
Guarded spawn, send, startup-memory, and stow commands preserve valid own-home and primary-to-mate routing ✅ pass live Focused end-to-end suite transcript
Only safe registry-corroborated identities participate in routing protection ✅ pass live Focused end-to-end suite transcript
  • bin/fm-session-start.sh
  • bin/fm-test-run.sh tests/fm-home-identity.test.sh --json ~/.no-mistakes/evidence/01M2M5WNS1YA07KND9XEC7VH61/fm-home-identity-results.json
  • Direct isolated target CLI execution of fm-send.sh and fm-spawn.sh
  • Direct isolated base-commit reproduction using git archive fe6ccb727b5fcb2520362044182e908aff16d12c
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

bingb0t5 and others added 21 commits September 16, 2026 10:59
FM_HOME selects which home's data/, state/, config/, and projects/ a
command operates on, so a secondmate process whose FM_HOME named another
home operated on that home with full authority. That happened twice: a
secondmate spawned a worker into the primary home, and a secondmate's
memory sweep read and rewrote the primary home's captain and learning
records. fm-spawn's primary-only domain-mate check could not stop the
first, because it inspects $FM_HOME - the value that was already wrong -
rather than the running process.

bin/fm-home-identity-lib.sh is the new owner of home identity and of the
refusal. fm-spawn, fm-send, fm-startup-memory-budget, and fm-stow-cascade
source it and exit 4 before any spawn, steer, or memory accounting when
the selected home is not this process's own. It fails closed on either of
two independent signals: the executing code root's own corroborated
secondmate identity, which also covers a sibling mate; and the
FM_PUBLIC_FOLLOWUP_PRIMARY_HOME session binding, which covers a mate that
invokes the primary home's own bin/ by absolute path. An identity marker
counts only when the home's durable parent binding and that parent's
registry place the same id at the same path, so a marker left behind in a
re-leased pool worktree establishes nothing.

The refusal is one-way: a primary home keeps reaching the mates it owns,
which the stow cascade, backlog handoff, and --secondmate spawns depend
on, while the primary home's own data stays read-only from every mate and
worker.

Also fix a test-hermeticity bug found on the way: the remote-secondmate
and cursor-primary fixtures built their fake code roots by tarring the
runner's checkout without excluding the gitignored home identity files, so
running the suite from any real secondmate home handed that home's
identity to the fixture and tripped the pre-existing primary-only guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRrBY9ykN3yGy1N2QbYzHh
…ve fixture in tests/fm-spawn-batch.test.sh to use the selected home’s canonical projects directory. Focused batch and home-identity tests pass, along with bash syntax and diff checks
@bingb0t5
bingb0t5 force-pushed the fm/fm-spawn-secondmate-fm-home-guard branch from 72da14d to 86de160 Compare September 16, 2026 04:28
@bingb0t5 bingb0t5 changed the title fix: prevent cross-home firstmate operations fix(bin): prevent cross-home firstmate operations Sep 16, 2026
@bingb0t5
bingb0t5 merged commit cad3386 into main Sep 16, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant